﻿using Microsoft.EntityFrameworkCore;

namespace FlexField.Metadata.EntityFrameworkCore
{
    /// <summary>
    /// Represents a new instance of a persistence store for the entities.
    /// </summary>
    /// <typeparam name="TContext">The type of the data context class used to access the store.</typeparam>
    /// <typeparam name="TKey">The type of the primary key for an entity.</typeparam>
    public class DataEntityStore<TContext, TKey> : IEntityStore<TKey>
        where TContext : DbContext
        where TKey : IEquatable<TKey>
    {
        private bool _disposed;

        /// <summary>
        /// Constructs a new instance of <see cref="DataEntityStore{TContext,TKey}"/>.
        /// </summary>
        /// <param name="context">The <see cref="MetadataDbContext"/>.</param>
        /// <param name="keyNormalizer">The <see cref="ILookupNormalizer"/> to use when generating index keys for types.</param>
        /// <param name="describer">The <see cref="MetadataErrorDescriber"/>.</param>
        public DataEntityStore(TContext context, ILookupNormalizer keyNormalizer = null,
            MetadataErrorDescriber describer = null)
        {
            Context = context ?? throw new ArgumentNullException(nameof(context));
            ErrorDescriber = describer ?? new MetadataErrorDescriber();
            KeyNormalizer = keyNormalizer;
        }

        /// <summary>
        /// Throws if this class has been disposed.
        /// </summary>
        protected void ThrowIfDisposed()
        {
            if (_disposed)
            {
                throw new ObjectDisposedException(GetType().Name);
            }
        }

        /// <summary>
        /// Dispose the stores
        /// </summary>
        public void Dispose() => _disposed = true;

        /// <summary>
        /// Gets the database context for this store.
        /// </summary>
        public virtual TContext Context { get; }

        /// <summary>
        /// The <see cref="ILookupNormalizer"/> used to normalize things like type names.
        /// </summary>
        public ILookupNormalizer KeyNormalizer { get; set; }

        /// <summary>
        /// Gets or sets the <see cref="MetadataErrorDescriber"/> for any error that occurred with the current operation.
        /// </summary>
        public MetadataErrorDescriber ErrorDescriber { get; }

        /// <summary>
        /// A navigation property for the entities the store contains.
        /// </summary>
        public virtual IQueryable<DataEntity<TKey>> DataEntities => Context.Set<DataEntity<TKey>>();

        private async Task<DataEntity<TKey>> FindDataTypeAsync(TKey entityId, CancellationToken cancellationToken)
        {
            return await DataEntities.Include(t => t.Properties)
                .SingleOrDefaultAsync(t => t.Id.Equals(entityId), cancellationToken);
        }

        private MetadataEntity<TKey> ConvertToMetadata(DataEntity<TKey> entity)
        {
            if (entity == null) return null;

            var metadata = new MetadataEntity<TKey>
            {
                Id = entity.Id,
                EntityName = entity.EntityName,
                DisplayName = entity.DisplayName,
                ConcurrencyStamp = entity.ConcurrencyStamp
            };
            foreach (var property in entity.Properties)
            {
                metadata.Properties[property.PropertyName] = property.PropertyValue;
            }

            return metadata;
        }

        /// <summary>
        /// Normalize type name for consistent comparisons.
        /// </summary>
        /// <param name="name">The name to normalize.</param>
        /// <returns>A normalized value representing the specified <paramref name="name"/>.</returns>
        protected virtual string NormalizeName(string name)
            => KeyNormalizer == null ? name : KeyNormalizer.NormalizeName(name);

        /// <inheritdoc />
        public virtual async Task<MetadataResult> CreateAsync(MetadataEntity<TKey> entity, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            if (entity == null)
            {
                throw new ArgumentNullException(nameof(entity));
            }

            await Context.AddAsync(new DataEntity<TKey>
            {
                EntityName = entity.EntityName,
                NormalizedEntityName = NormalizeName(entity.EntityName),
                DisplayName = entity.DisplayName,
                ConcurrencyStamp = entity.ConcurrencyStamp,
                Id = entity.Id
            }, cancellationToken);

            foreach (var (key, value) in entity.Properties)
            {
                await Context.AddAsync(new DataEntityProperty<TKey>
                {
                    EntityId = entity.Id,
                    PropertyName = key,
                    NormalizedPropertyName = NormalizeName(key),
                    PropertyValue = value
                }, cancellationToken);
            }

            await Context.SaveChangesAsync(cancellationToken);
            return MetadataResult.Success;
        }

        /// <inheritdoc />
        public virtual async Task<MetadataResult> UpdateAsync(MetadataEntity<TKey> entity, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            if (entity == null)
            {
                throw new ArgumentNullException(nameof(entity));
            }

            var dataType = await FindDataTypeAsync(entity.Id, cancellationToken);
            if (dataType == null)
            {
                return MetadataResult.Failed(ErrorDescriber.TypeNotFound(entity.Id.ToString()));
            }

            dataType.EntityName = entity.EntityName;
            dataType.NormalizedEntityName = NormalizeName(entity.EntityName);
            dataType.DisplayName = entity.DisplayName;
            Context.Entry(dataType).OriginalValues[nameof(DataEntity<TKey>.ConcurrencyStamp)] = entity.ConcurrencyStamp;
            dataType.ConcurrencyStamp = Guid.NewGuid().ToString();

            var properties = dataType.Properties.ToList();
            foreach (var (key, value) in entity.Properties)
            {
                var normalizedPropertyName = NormalizeName(key);
                var dataProperty = properties.Find(property => property.NormalizedPropertyName == normalizedPropertyName);
                if (dataProperty == null)
                {
                    await Context.AddAsync(dataProperty = new DataEntityProperty<TKey>
                    {
                        EntityId = entity.Id,
                        PropertyName = key,
                        NormalizedPropertyName = normalizedPropertyName
                    }, cancellationToken);
                }
                else
                {
                    properties.Remove(dataProperty);
                }
                dataProperty.PropertyValue = value;
            }

            Context.RemoveRange(properties);
            try
            {
                await Context.SaveChangesAsync(cancellationToken);
                entity.ConcurrencyStamp = dataType.ConcurrencyStamp;
            }
            catch (DbUpdateConcurrencyException)
            {
                return MetadataResult.Failed(ErrorDescriber.ConcurrencyFailure());
            }

            return MetadataResult.Success;
        }

        /// <inheritdoc />
        public virtual async Task<MetadataResult> DeleteAsync(MetadataEntity<TKey> entity, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            if (entity == null)
            {
                throw new ArgumentNullException(nameof(entity));
            }

            var dataType = await FindDataTypeAsync(entity.Id, cancellationToken);
            if (dataType == null)
            {
                return MetadataResult.Failed(ErrorDescriber.TypeNotFound(entity.Id.ToString()));
            }

            Context.RemoveRange(dataType.Properties);
            Context.Entry(dataType).OriginalValues[nameof(DataEntity<TKey>.ConcurrencyStamp)] = entity.ConcurrencyStamp;
            dataType.ConcurrencyStamp = Guid.NewGuid().ToString();
            Context.Remove(dataType);

            try
            {
                await Context.SaveChangesAsync(cancellationToken);
            }
            catch (DbUpdateConcurrencyException)
            {
                return MetadataResult.Failed(ErrorDescriber.ConcurrencyFailure());
            }

            return MetadataResult.Success;
        }

        /// <inheritdoc />
        public virtual async Task<MetadataEntity<TKey>> FindByIdAsync(TKey entityId, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            return ConvertToMetadata(await FindDataTypeAsync(entityId, cancellationToken));
        }

        /// <inheritdoc />
        public virtual async Task<MetadataEntity<TKey>> FindByNameAsync(string entityName, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            entityName = NormalizeName(entityName);
            return ConvertToMetadata(await DataEntities.Include(t => t.Properties)
                .SingleOrDefaultAsync(t => t.NormalizedEntityName == entityName, cancellationToken));
        }

        /// <inheritdoc />
        public virtual async Task<IList<MetadataEntity<TKey>>> GetAllAsync(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            return (await DataEntities.Include(type => type.Properties).OrderBy(type => type.DisplayName ?? type.EntityName)
                .ToArrayAsync(cancellationToken)).Select(ConvertToMetadata).ToArray();
        }

        /// <inheritdoc />
        public virtual async Task<IList<MetadataEntity<TKey>>> FindByPropertyAsync(string propertyName, string propertyValue, CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            ThrowIfDisposed();
            propertyName = NormalizeName(propertyName);
            return (await DataEntities.Include(t => t.Properties).OrderBy(type => type.DisplayName ?? type.EntityName)
                .Where(t => t.Properties.Any(p => p.NormalizedPropertyName == propertyName && p.PropertyValue == propertyValue))
                .ToArrayAsync(cancellationToken: cancellationToken)).Select(ConvertToMetadata).ToArray();
        }
    }
}
